Skip to content

feat: add shadow-styles export for Shadow DOM integration#1027

Open
makhnatkin wants to merge 4 commits intomainfrom
claude/nice-lederberg
Open

feat: add shadow-styles export for Shadow DOM integration#1027
makhnatkin wants to merge 4 commits intomainfrom
claude/nice-lederberg

Conversation

@makhnatkin
Copy link
Copy Markdown
Collaborator

@makhnatkin makhnatkin commented Mar 24, 2026

Summary

Adds @gravity-ui/markdown-editor/shadow-styles for Shadow DOM usage.

import {cssText, createStyleSheet} from '@gravity-ui/markdown-editor/shadow-styles';
  • cssText gives you the bundled editor CSS as a string
  • createStyleSheet() returns a ready-to-use CSSStyleSheet
  • Includes editor styles and default @diplodoc/* CSS used by the editor
  • Does not include @gravity-ui/uikit styles

Also adds build/test/CI coverage to keep this export in sync.

Closes #1026

@makhnatkin makhnatkin requested a review from d3m1d0v as a code owner March 24, 2026 14:11
@gravity-ui
Copy link
Copy Markdown

gravity-ui Bot commented Mar 24, 2026

Storybook Deployed

@gravity-ui
Copy link
Copy Markdown

gravity-ui Bot commented Mar 24, 2026

🎭 Playwright Report

@makhnatkin makhnatkin marked this pull request as draft March 25, 2026 21:16
@makhnatkin makhnatkin force-pushed the claude/nice-lederberg branch from 8ca0e13 to cee414e Compare May 5, 2026 20:54
@makhnatkin makhnatkin marked this pull request as ready for review May 5, 2026 21:07
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 5, 2026

Reviewer's Guide

Adds a new Shadow DOM–friendly stylesheet export (./shadow-styles) that bundles compiled editor CSS and external @diplodoc CSS, wires it into the build pipeline and package exports, and introduces CI + tests to keep the import list and runtime behavior correct (including optional peer handling).

File-Level Changes

Change Details Files
Introduce a shadow-styles build artifact that bundles editor and external CSS and exposes both ESM and CJS helpers.
  • Extend gulpfile to define SHADOW_STYLE_IMPORTS for external CSS and OPTIONAL_PEERS based on peerDependenciesMeta.optional.
  • Add a shadow-styles gulp task that reads external CSS + built styles.css, concatenates them, and emits shadow-styles.mjs, shadow-styles.cjs, and shadow-styles.d.ts.
  • Generate module code that exports cssText and a createStyleSheet() helper, with a runtime check for CSSStyleSheet availability.
  • Adjust the main build task to run TS/JSON/SCSS in parallel and then run the new shadow-styles step.
packages/editor/gulpfile.mjs
Add smoke tests to validate both CJS and ESM shadow-styles outputs and the createStyleSheet helper.
  • Refactor esbuild-tester into an async run() function with explicit cleanup.
  • Shim globalThis.CSSStyleSheet with a test implementation that records cssText for validation.
  • Require/import the built shadow-styles CJS and ESM modules, asserting non-empty cssText, matching content, and working createStyleSheet() that populates CSSStyleSheet instances.
  • Ensure esbuild tests still build the original test entrypoints after shadow-styles validation and restore the original CSSStyleSheet global in a finally block.
packages/editor/tests/esbuild-test/esbuild-tester.js
Introduce CI and package scripts to enforce shadow styles import drift and wire the new tests into the monorepo test pipeline.
  • Add a test:shadow-styles script in packages/editor to run the new drift-check script.
  • Expose ci:test:shadow-styles in the root package and include it in the aggregated test command.
  • Add a dedicated check_shadow_styles_imports GitHub Actions job to run the drift check in CI with pnpm + Node setup.
packages/editor/package.json
.github/workflows/ci.yml
package.json
Add a drift-protection script that keeps SHADOW_STYLE_IMPORTS aligned with actual non-relative CSS imports in the editor source tree.
  • Implement scripts/check-shadow-styles-imports.js to import SHADOW_STYLE_IMPORTS from gulpfile.mjs and scan src/** for bare .css imports using a regex.
  • Exclude @gravity-ui/* CSS imports from the drift check and only consider non-relative imports.
  • Compare declared vs actual imports, reporting missing and stale entries and failing the process when drift is detected.
  • Provide a summary success message when declared and actual sets match.
packages/editor/scripts/check-shadow-styles-imports.js
Expose the new shadow-styles entrypoint as a dedicated subpath export for consumers.
  • Add a ./shadow-styles export in packages/editor/package.json that points to the generated shadow-styles.mjs/cjs and associated .d.ts for both import and require consumers.
packages/editor/package.json

Assessment against linked issues

Issue Objective Addressed Explanation
#1026 Provide a public API to obtain all editor CSS in a form suitable for injection into a Shadow DOM.
#1026 Ensure the exposed Shadow DOM styles include the editor’s compiled styles and required default extension CSS, and document how to use them.
#1026 Integrate the Shadow DOM styles export into the build and CI pipeline so it is generated and kept in sync with editor CSS imports.

Possibly linked issues

  • Shadow dom support #1026: PR adds a shadow-styles export giving full editor CSS for straightforward Shadow DOM injection, resolving the issue

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The EXTERNAL_CSS_IMPORT_RE approach hard-codes a particular import shape (single-line, static, bare module, .css suffix) and may miss or mis-detect imports as code evolves; consider either broadening the regex to handle multiline/named-only imports or switching to a simple AST-based scan so TS/JS formatting changes don’t silently break CSS collection.
  • In collectExternalCss, require.resolve(cssImport) will throw if a referenced CSS module is missing; if this is expected to be robust in different consumer setups, you might want to add a clearer error message or guard around unresolved imports so build failures are easier to diagnose.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `EXTERNAL_CSS_IMPORT_RE` approach hard-codes a particular import shape (single-line, static, bare module, `.css` suffix) and may miss or mis-detect imports as code evolves; consider either broadening the regex to handle multiline/named-only imports or switching to a simple AST-based scan so TS/JS formatting changes don’t silently break CSS collection.
- In `collectExternalCss`, `require.resolve(cssImport)` will throw if a referenced CSS module is missing; if this is expected to be robust in different consumer setups, you might want to add a clearer error message or guard around unresolved imports so build failures are easier to diagnose.

## Individual Comments

### Comment 1
<location path="packages/editor/gulpfile.mjs" line_range="26" />
<code_context>
     nodeModulesDir: NODE_MODULES_DIR,
 });

+task('styles-string', (done) => {
+    const externalCss = collectExternalCss();
+    const editorCss = readFileSync(resolve(BUILD_DIR, 'styles.css'), 'utf8');
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the CSS aggregation and helper logic into a separate module so the gulpfile remains a small, declarative task wiring file.

You can keep the new functionality but move the “mini build system” out of the gulpfile so the gulpfile stays small and easy to scan.

### 1. Extract helpers into a separate module

Create a dedicated helper file (e.g. `build/styles-string.mjs`) and move the regex, FS traversal, and template-escaping there:

```js
// build/styles-string.mjs
import {readFileSync, readdirSync} from 'node:fs';
import {createRequire} from 'node:module';
import {extname, resolve} from 'node:path';

const require = createRequire(import.meta.url);
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
const EXTERNAL_CSS_IMPORT_RE =
    /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm;

export function createStylesString({buildDir, sourceDir}) {
    const externalCss = collectExternalCss(sourceDir);
    const editorCss = readFileSync(resolve(buildDir, 'styles.css'), 'utf8');
    const styles = [externalCss, editorCss].filter(Boolean).join('\n');
    return toTemplateLiteral(styles);
}

function collectExternalCss(sourceDir) {
    const cssImports = new Set();

    for (const sourceFile of getSourceFiles(sourceDir)) {
        const sourceCode = readFileSync(sourceFile, 'utf8');

        for (const match of sourceCode.matchAll(EXTERNAL_CSS_IMPORT_RE)) {
            cssImports.add(match[1]);
        }
    }

    return Array.from(cssImports)
        .map((cssImport) => readFileSync(require.resolve(cssImport), 'utf8'))
        .join('\n');
}

function getSourceFiles(dir) {
    return readdirSync(dir, {withFileTypes: true})
        // drop sort() if deterministic order is not required
        .flatMap((entry) => {
            const entryPath = resolve(dir, entry.name);

            if (entry.isDirectory()) {
                return getSourceFiles(entryPath);
            }

            return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : [];
        });
}

function toTemplateLiteral(value) {
    return `\`${value
        .replace(/\\/g, '\\\\')
        .replace(/`/g, '\\`')
        .replace(/\$\{/g, '\\${')}\``;
}
```

### 2. Keep the gulpfile focused on task wiring

Then the gulpfile only wires tasks together and delegates the logic:

```js
// gulpfile.mjs
import {writeFileSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parallel, series, task} from '@markdown-editor/gulp-tasks';
import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build';
import {createStylesString} from './build/styles-string.mjs';

import pkg from './package.json' with {type: 'json'};

const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILD_DIR = resolve('build');
const SOURCE_DIR = resolve('src');

registerBuildTasks({
    version: pkg.version,
    buildDir: BUILD_DIR,
    nodeModulesDir: resolve(__dirname, 'node_modules'),
});

task('styles-string', (done) => {
    const content = createStylesString({buildDir: BUILD_DIR, sourceDir: SOURCE_DIR});

    writeFileSync(resolve(BUILD_DIR, 'styles-string.mjs'), `export default ${content};\n`);
    writeFileSync(resolve(BUILD_DIR, 'styles-string.cjs'), `module.exports = ${content};\n`);
    writeFileSync(
        resolve(BUILD_DIR, 'styles-string.d.ts'),
        'declare const styles: string;\nexport default styles;\n',
    );

    done();
});

task('build', series(parallel('ts', 'json', 'scss'), 'styles-string'));
task('default', series('clean', 'build'));
```

This keeps all behavior intact but:

- The gulpfile returns to being a small, declarative task definition.
- The complex parts (regex parsing, traversal, escaping) live in an isolated module that you can unit test independently.
- Future readers only need to dive into `build/styles-string.mjs` when they care about the details of CSS aggregation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/editor/gulpfile.mjs Outdated
nodeModulesDir: NODE_MODULES_DIR,
});

task('styles-string', (done) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the CSS aggregation and helper logic into a separate module so the gulpfile remains a small, declarative task wiring file.

You can keep the new functionality but move the “mini build system” out of the gulpfile so the gulpfile stays small and easy to scan.

1. Extract helpers into a separate module

Create a dedicated helper file (e.g. build/styles-string.mjs) and move the regex, FS traversal, and template-escaping there:

// build/styles-string.mjs
import {readFileSync, readdirSync} from 'node:fs';
import {createRequire} from 'node:module';
import {extname, resolve} from 'node:path';

const require = createRequire(import.meta.url);
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
const EXTERNAL_CSS_IMPORT_RE =
    /^\s*import\s+(?:.+?\s+from\s+)?['"]([^./'"][^'"]*\.css)['"];?/gm;

export function createStylesString({buildDir, sourceDir}) {
    const externalCss = collectExternalCss(sourceDir);
    const editorCss = readFileSync(resolve(buildDir, 'styles.css'), 'utf8');
    const styles = [externalCss, editorCss].filter(Boolean).join('\n');
    return toTemplateLiteral(styles);
}

function collectExternalCss(sourceDir) {
    const cssImports = new Set();

    for (const sourceFile of getSourceFiles(sourceDir)) {
        const sourceCode = readFileSync(sourceFile, 'utf8');

        for (const match of sourceCode.matchAll(EXTERNAL_CSS_IMPORT_RE)) {
            cssImports.add(match[1]);
        }
    }

    return Array.from(cssImports)
        .map((cssImport) => readFileSync(require.resolve(cssImport), 'utf8'))
        .join('\n');
}

function getSourceFiles(dir) {
    return readdirSync(dir, {withFileTypes: true})
        // drop sort() if deterministic order is not required
        .flatMap((entry) => {
            const entryPath = resolve(dir, entry.name);

            if (entry.isDirectory()) {
                return getSourceFiles(entryPath);
            }

            return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : [];
        });
}

function toTemplateLiteral(value) {
    return `\`${value
        .replace(/\\/g, '\\\\')
        .replace(/`/g, '\\`')
        .replace(/\$\{/g, '\\${')}\``;
}

2. Keep the gulpfile focused on task wiring

Then the gulpfile only wires tasks together and delegates the logic:

// gulpfile.mjs
import {writeFileSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {parallel, series, task} from '@markdown-editor/gulp-tasks';
import {registerBuildTasks} from '@markdown-editor/gulp-tasks/build';
import {createStylesString} from './build/styles-string.mjs';

import pkg from './package.json' with {type: 'json'};

const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILD_DIR = resolve('build');
const SOURCE_DIR = resolve('src');

registerBuildTasks({
    version: pkg.version,
    buildDir: BUILD_DIR,
    nodeModulesDir: resolve(__dirname, 'node_modules'),
});

task('styles-string', (done) => {
    const content = createStylesString({buildDir: BUILD_DIR, sourceDir: SOURCE_DIR});

    writeFileSync(resolve(BUILD_DIR, 'styles-string.mjs'), `export default ${content};\n`);
    writeFileSync(resolve(BUILD_DIR, 'styles-string.cjs'), `module.exports = ${content};\n`);
    writeFileSync(
        resolve(BUILD_DIR, 'styles-string.d.ts'),
        'declare const styles: string;\nexport default styles;\n',
    );

    done();
});

task('build', series(parallel('ts', 'json', 'scss'), 'styles-string'));
task('default', series('clean', 'build'));

This keeps all behavior intact but:

  • The gulpfile returns to being a small, declarative task definition.
  • The complex parts (regex parsing, traversal, escaping) live in an isolated module that you can unit test independently.
  • Future readers only need to dive into build/styles-string.mjs when they care about the details of CSS aggregation.

@makhnatkin makhnatkin changed the title feat: export CSS as string module for Shadow DOM injection feat: add shadow-styles export for Shadow DOM integration May 6, 2026
Add a regression script that diffs SHADOW_STYLE_IMPORTS against
non-relative *.css imports under packages/editor/src/**, so a future
extension addition cannot silently leave shadow-styles cssText stale
behind a green CI. Wired as a separate ci:test:shadow-styles job
mirroring the circular-deps check.

Wrap require.resolve() for shadow-styles externals in a try/catch:
optional peer packages (per peerDependenciesMeta) are skipped with a
warning instead of crashing the build for consumers that don't install
them. Required peers still throw.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@makhnatkin
Copy link
Copy Markdown
Collaborator Author

@sourcery-ai review

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The CSS_IMPORT_RE in check-shadow-styles-imports.js only matches bare side-effect imports (e.g. import 'x.css'); if any file ever uses import x from 'x.css' or other syntaxes, those CSS dependencies will be missed, so consider broadening the regex to cover all import ... from '...css' forms.
  • The drift-check script dynamically imports gulpfile.mjs just to read SHADOW_STYLE_IMPORTS, which also runs all gulpfile side effects; consider moving SHADOW_STYLE_IMPORTS into a small shared module that both the gulpfile and the script can import without triggering task registration.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `CSS_IMPORT_RE` in `check-shadow-styles-imports.js` only matches bare side-effect imports (e.g. `import 'x.css'`); if any file ever uses `import x from 'x.css'` or other syntaxes, those CSS dependencies will be missed, so consider broadening the regex to cover all `import ... from '...css'` forms.
- The drift-check script dynamically imports `gulpfile.mjs` just to read `SHADOW_STYLE_IMPORTS`, which also runs all gulpfile side effects; consider moving `SHADOW_STYLE_IMPORTS` into a small shared module that both the gulpfile and the script can import without triggering task registration.

## Individual Comments

### Comment 1
<location path="packages/editor/scripts/check-shadow-styles-imports.js" line_range="9-10" />
<code_context>
+const SRC_DIR = path.resolve(__dirname, '..', 'src');
+const GULPFILE_URL = pathToFileURL(path.resolve(__dirname, '..', 'gulpfile.mjs')).href;
+
+// Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css.
+const CSS_IMPORT_RE = /(?:^|\s)import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm;
+
+const EXCLUDED_SCOPES = ['@gravity-ui/'];
</code_context>
<issue_to_address>
**suggestion:** The regex will also pick up commented-out imports, which can cause noisy false positives.

Since `CSS_IMPORT_RE` doesn’t account for comments, lines like `// import '@foo/bar.css';` or imports inside block comments will still be matched and counted, potentially failing the drift check on dead imports. To avoid this, either narrow the regex (e.g., anchor to `^\s*import` and ensure it’s not preceded by `//`) or pre-strip line and block comments before applying the regex.

Suggested implementation:

```javascript
 // Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css.
 // Note: anchored to the start of the line to avoid matching inline occurrences.
 const CSS_IMPORT_RE = /^\s*import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm;

 const EXCLUDED_SCOPES = ['@gravity-ui/'];

 // Remove line (`// ...`) and block (`/* ... */`) comments from source code.
 const LINE_COMMENT_RE = /\/\/[^\n\r]*/g;
 const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;

 function stripComments(source) {
     if (typeof source !== 'string') {
         return '';
     }

     // Remove block comments first, then line comments.
     return source.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '');
 }

```

` since that part isn’t shown).

Here are the edits:

<file_operations>
<file_operation operation="edit" file_path="packages/editor/scripts/check-shadow-styles-imports.js">
<<<<<<< SEARCH
// Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css.
const CSS_IMPORT_RE = /(?:^|\s)import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm;

const EXCLUDED_SCOPES = ['@gravity-ui/'];
=======
 // Bare `import 'pkg/path/file.css';` — non-relative, scoped or unscoped, ending in .css.
 // Note: anchored to the start of the line to avoid matching inline occurrences.
 const CSS_IMPORT_RE = /^\s*import\s+['"]((?:@[^'"\s/]+\/)?[^'"\s.][^'"\s]*\.css)['"]/gm;

 const EXCLUDED_SCOPES = ['@gravity-ui/'];

 // Remove line (`// ...`) and block (`/* ... */`) comments from source code.
 const LINE_COMMENT_RE = /\/\/[^\n\r]*/g;
 const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;

 function stripComments(source) {
     if (typeof source !== 'string') {
         return '';
     }

     // Remove block comments first, then line comments.
     return source.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '');
 }
>>>>>>> REPLACE
</file_operation>
</file_operations>

<additional_changes>
To fully wire this up you should:

1. In `collectCssImports` (or wherever file contents are scanned using `CSS_IMPORT_RE`), ensure you call `stripComments` on the file contents before applying the regex. For example, if you currently have something like:
   ```js
   const content = fs.readFileSync(filePath, 'utf8');
   let match;
   while ((match = CSS_IMPORT_RE.exec(content)) !== null) {
       // ...
   }
   ```
   change it to:
   ```js
   const content = fs.readFileSync(filePath, 'utf8');
   const uncommented = stripComments(content);
   let match;
   while ((match = CSS_IMPORT_RE.exec(uncommented)) !== null) {
       // ...
   }
   ```

2. If `CSS_IMPORT_RE` is reused across multiple files or helper functions in this script, ensure all of them pass the source through `stripComments` first. This will prevent commented-out imports (both `// import '...'` and imports inside `/* ... */`) from being counted in the drift check.
</issue_to_address>

### Comment 2
<location path="packages/editor/gulpfile.mjs" line_range="87" />
<code_context>
+    return OPTIONAL_PEERS.has(pkgName);
+}
+
+function createShadowStylesModule(value) {
+    const cssText = toTemplateLiteral(value);
+    const createStyleSheetBody = [
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the shadow-styles generation by using JSON.stringify-based string literals, a shared multi-line snippet for createStyleSheet, and extracting optional-peer/CSS logic into a helper module to keep the gulpfile focused on task wiring.

You can keep the current behavior but reduce complexity in a few focused spots:

### 1. Drop custom template-literal escaping

`toTemplateLiteral` can be removed by emitting a plain string literal via `JSON.stringify`, which handles all escaping for you and is easier to reason about.

```js
function createShadowStylesModule(value) {
    const cssJson = JSON.stringify(value);

    const createStyleSheetFn = `
function createStyleSheet() {
    if (typeof CSSStyleSheet === 'undefined') {
        throw new Error('Constructable stylesheets are not available in this environment.');
    }
    const styleSheet = new CSSStyleSheet();
    styleSheet.replaceSync(cssText);
    return styleSheet;
}
`;

    return {
        esm: [
            `export const cssText = ${cssJson};`,
            createStyleSheetFn,
            'export {createStyleSheet};',
            '',
        ].join('\n'),
        cjs: [
            `const cssText = ${cssJson};`,
            createStyleSheetFn,
            'exports.cssText = cssText;',
            'exports.createStyleSheet = createStyleSheet;',
            '',
        ].join('\n'),
    };
}
```

Then you can delete `toTemplateLiteral` entirely.

### 2. Make codegen more readable

Instead of building `createStyleSheet` as an array of lines and injecting it twice, use a single multi-line string (as above). This keeps the logic readable and easy to edit while still generating both ESM and CJS variants.

If you want to simplify further, you can factor the common snippet out:

```js
const CREATE_STYLESHEET_SNIPPET = `
function createStyleSheet() {
    if (typeof CSSStyleSheet === 'undefined') {
        throw new Error('Constructable stylesheets are not available in this environment.');
    }
    const styleSheet = new CSSStyleSheet();
    styleSheet.replaceSync(cssText);
    return styleSheet;
}
`;
```

and reuse `CREATE_STYLESHEET_SNIPPET` in `createShadowStylesModule`.

### 3. Move optional-peer logic into a helper module

To keep the gulpfile focused on task wiring, push the peer-dependency parsing into a small helper. Behavior stays the same but the gulpfile reads more linearly.

`scripts/shadow-styles-helpers.js`:

```js
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import pkg from '../package.json' with {type: 'json'};

const require = createRequire(import.meta.url);

const OPTIONAL_PEERS = new Set(
    Object.entries(pkg.peerDependenciesMeta ?? {})
        .filter(([, meta]) => meta?.optional)
        .map(([name]) => name),
);

export const SHADOW_STYLE_IMPORTS = Object.freeze([
    '@diplodoc/transform/dist/css/base.css',
    '@diplodoc/transform/dist/css/_yfm-only.css',
    '@diplodoc/cut-extension/runtime/styles.css',
    '@diplodoc/file-extension/runtime/styles.css',
    '@diplodoc/tabs-extension/runtime/styles.css',
    '@diplodoc/quote-link-extension/runtime/styles.css',
    '@diplodoc/folding-headings-extension/runtime/styles.css',
]);

export function readExternalShadowStyles() {
    return SHADOW_STYLE_IMPORTS.map((cssImport) => {
        try {
            return readFileSync(require.resolve(cssImport), 'utf8');
        } catch (err) {
            if (err?.code === 'MODULE_NOT_FOUND' && isOptionalPeerImport(cssImport)) {
                console.warn(
                    `[shadow-styles] Skipping optional peer CSS '${cssImport}' (package not installed).`,
                );
                return '';
            }
            throw err;
        }
    }).filter(Boolean).join('\n');
}

function isOptionalPeerImport(cssImport) {
    const pkgName = cssImport.startsWith('@')
        ? cssImport.split('/', 2).join('/')
        : cssImport.split('/', 1)[0];
    return OPTIONAL_PEERS.has(pkgName);
}
```

`gulpfile` snippet:

```js
import {readExternalShadowStyles, SHADOW_STYLE_IMPORTS} from './scripts/shadow-styles-helpers.js';

// ... use readExternalShadowStyles() in the 'shadow-styles' task as you do now
```

This keeps all functionality but reduces the cognitive load in the gulpfile and makes the CSS/peer logic easier to test and evolve independently.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread packages/editor/scripts/check-shadow-styles-imports.js Outdated
return OPTIONAL_PEERS.has(pkgName);
}

function createShadowStylesModule(value) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying the shadow-styles generation by using JSON.stringify-based string literals, a shared multi-line snippet for createStyleSheet, and extracting optional-peer/CSS logic into a helper module to keep the gulpfile focused on task wiring.

You can keep the current behavior but reduce complexity in a few focused spots:

1. Drop custom template-literal escaping

toTemplateLiteral can be removed by emitting a plain string literal via JSON.stringify, which handles all escaping for you and is easier to reason about.

function createShadowStylesModule(value) {
    const cssJson = JSON.stringify(value);

    const createStyleSheetFn = `
function createStyleSheet() {
    if (typeof CSSStyleSheet === 'undefined') {
        throw new Error('Constructable stylesheets are not available in this environment.');
    }
    const styleSheet = new CSSStyleSheet();
    styleSheet.replaceSync(cssText);
    return styleSheet;
}
`;

    return {
        esm: [
            `export const cssText = ${cssJson};`,
            createStyleSheetFn,
            'export {createStyleSheet};',
            '',
        ].join('\n'),
        cjs: [
            `const cssText = ${cssJson};`,
            createStyleSheetFn,
            'exports.cssText = cssText;',
            'exports.createStyleSheet = createStyleSheet;',
            '',
        ].join('\n'),
    };
}

Then you can delete toTemplateLiteral entirely.

2. Make codegen more readable

Instead of building createStyleSheet as an array of lines and injecting it twice, use a single multi-line string (as above). This keeps the logic readable and easy to edit while still generating both ESM and CJS variants.

If you want to simplify further, you can factor the common snippet out:

const CREATE_STYLESHEET_SNIPPET = `
function createStyleSheet() {
    if (typeof CSSStyleSheet === 'undefined') {
        throw new Error('Constructable stylesheets are not available in this environment.');
    }
    const styleSheet = new CSSStyleSheet();
    styleSheet.replaceSync(cssText);
    return styleSheet;
}
`;

and reuse CREATE_STYLESHEET_SNIPPET in createShadowStylesModule.

3. Move optional-peer logic into a helper module

To keep the gulpfile focused on task wiring, push the peer-dependency parsing into a small helper. Behavior stays the same but the gulpfile reads more linearly.

scripts/shadow-styles-helpers.js:

import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import pkg from '../package.json' with {type: 'json'};

const require = createRequire(import.meta.url);

const OPTIONAL_PEERS = new Set(
    Object.entries(pkg.peerDependenciesMeta ?? {})
        .filter(([, meta]) => meta?.optional)
        .map(([name]) => name),
);

export const SHADOW_STYLE_IMPORTS = Object.freeze([
    '@diplodoc/transform/dist/css/base.css',
    '@diplodoc/transform/dist/css/_yfm-only.css',
    '@diplodoc/cut-extension/runtime/styles.css',
    '@diplodoc/file-extension/runtime/styles.css',
    '@diplodoc/tabs-extension/runtime/styles.css',
    '@diplodoc/quote-link-extension/runtime/styles.css',
    '@diplodoc/folding-headings-extension/runtime/styles.css',
]);

export function readExternalShadowStyles() {
    return SHADOW_STYLE_IMPORTS.map((cssImport) => {
        try {
            return readFileSync(require.resolve(cssImport), 'utf8');
        } catch (err) {
            if (err?.code === 'MODULE_NOT_FOUND' && isOptionalPeerImport(cssImport)) {
                console.warn(
                    `[shadow-styles] Skipping optional peer CSS '${cssImport}' (package not installed).`,
                );
                return '';
            }
            throw err;
        }
    }).filter(Boolean).join('\n');
}

function isOptionalPeerImport(cssImport) {
    const pkgName = cssImport.startsWith('@')
        ? cssImport.split('/', 2).join('/')
        : cssImport.split('/', 1)[0];
    return OPTIONAL_PEERS.has(pkgName);
}

gulpfile snippet:

import {readExternalShadowStyles, SHADOW_STYLE_IMPORTS} from './scripts/shadow-styles-helpers.js';

// ... use readExternalShadowStyles() in the 'shadow-styles' task as you do now

This keeps all functionality but reduces the cognitive load in the gulpfile and makes the CSS/peer logic easier to test and evolve independently.

Decouple drift-check from gulpfile by extracting SHADOW_STYLE_IMPORTS
into scripts/shadow-styles-imports.mjs, so the CI check no longer
dynamically imports the gulpfile (which would also run
registerBuildTasks side effects) just to read one constant.

Broaden the drift-check regex to cover non-side-effect import forms
(`import x from 'pkg/x.css'`, `import * as x from`, named imports, and
dynamic `import('pkg/x.css')`), and strip block/line comments before
matching so commented-out imports do not produce false positives.
Split into two regexes (static vs dynamic) for readability.

Add an `assertNoCssImportRules` guard in the `shadow-styles` gulp task:
`CSSStyleSheet.replaceSync()` rejects `@import` rules at runtime, so
fail the build instead of letting Shadow DOM consumers crash. The guard
strips comments and string literals first to avoid false positives on
`@import` substrings inside CSS values.

Fold the standalone `Check Shadow Styles Imports` CI job into the
existing `tests` job — saves a redundant pnpm install (~30-60s) per PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shadow dom support

1 participant